#!/usr/bin/env python
# coding: utf-8

"""
Contains the logic needed to support the cscope Textmate Bundle, including interfacing with cscope itself.
"""

__version__ = "$Revision$"
# $Source$


import os
import sys
import subprocess

# Exceptions
class CscopeError(Exception): pass

# Errors when running the cscope binary
class CscopeRunError(CscopeError): pass

# No Textmate project is defines
class CscopeProjectError(CscopeError): pass

# User aborted a query
class CscopeQueryError(CscopeError): pass


# Cscope result line constants
ITEMS_PER_LINE = 4
INDEX_FILE = 0
INDEX_SCOPE = 1
INDEX_NUMBER = 2
INDEX_TEXT = 3

# MAX_RESULTS_MENU


# The cscope queries, sorted by cscope index
CSCOPE_QUERIES_INDEX = ['symbol', 'definition', 'called', 'calling', 'string', '_unsupported', 'egrep', 'file', 'including']

# The cscope queries and their long description (dictionary)
CSCOPE_QUERIES_DESC = { CSCOPE_QUERIES_INDEX[0]: "Find this symbol",
                        CSCOPE_QUERIES_INDEX[1]: "Find this function definition",
                        CSCOPE_QUERIES_INDEX[2]: "Find functions called by this function",
                        CSCOPE_QUERIES_INDEX[3]: "Find functions calling this function",
                        CSCOPE_QUERIES_INDEX[4]: "Find this text string",
                        CSCOPE_QUERIES_INDEX[5]: "Change this text string",
                        CSCOPE_QUERIES_INDEX[6]: "Find this egrep pattern",
                        CSCOPE_QUERIES_INDEX[7]: "Find this file",
                        CSCOPE_QUERIES_INDEX[8]: "Find files #including this file",}

# Description mapping to the query name
CSCOPE_QUERIES_DESC_REV = dict((string, query) for query, string in CSCOPE_QUERIES_DESC.iteritems())

class CscopeBundle:
    ##
    # A list of all possible cscope commands in the correct order.
    #
    # 0: Find this C symbol
    # 1: Find this function definition
    # 2: Find functions called by this function
    # 3: Find functions calling this function
    # 4: Find this text string
    # 5: Change this text string (Not supported)
    # 6: Find this egrep pattern
    # 7: Find this file
    # 8: Find files #including this file
    
    
    def __init__(self):
        self.tm_support_path = self.get_environ('TM_SUPPORT_PATH')
        sys.path.append(os.path.join(self.tm_support_path, 'lib'))
        
        # Current selection in TM
        self.selected_text = self.get_environ('TM_SELECTED_TEXT')
        
        # Current word in tm
        self.current_word = self.get_environ('TM_CURRENT_WORD')
        
        # Current contents of the find pasteboard
        self.current_find = self.get_pasteboard()
        
        # Current project directory
        self.project_dir = self.get_environ('TM_PROJECT_DIRECTORY')
        
        # The localtion of the bundle's support directory
        self.bundle_dir = self.get_environ('TM_BUNDLE_SUPPORT')
        
        # The textmate dialog execitable
        self.tm_dialog = self.get_environ('DIALOG')
        
        # Full path to the cscope binary
        self.cscope = os.path.join(self.bundle_dir, 'cscope')
        
        # Path to the query history file
        self.query_history = os.path.join(self.bundle_dir, 'query_history.plist')
        self.max_history_lines = 10
        
        # Name of the file where the settings for the bundle are kept
        self.cscope_settings = os.path.join(self.bundle_dir, 'cscope.tmbundle.settings.plist')
        
        self.init_settings()
    
    
    def error(self, message, details=""):
        """Report error to end user in a friendly way."""
        print message  + details

        
    def error_html(self, message, details=""):
        """Generate error to be displayed to the end user in a friendly way as an HTML."""
        return "<strong>Error:</strong> " + message + ", " + details
    
    
    def init_settings(self):
        """Initializes the bundle settings from the plist file."""
        
        import plistlib
                
        settings = plistlib.readPlist(self.cscope_settings)
        
        # Figure out what to populate the current query with (order of priority)
        self.query =  self.selected_text or self.current_word or self.current_find or ""
        self.query = self.query.strip()
                
        # Get the cscope parameters
        self.cscope_files = os.path.join(self.project_dir, settings['fileName'])
        self.kernel_mode = bool(settings['kernelMode'])
        self.ignore_case = bool(settings['ignoreCase'])
        self.not_always_update = bool(settings['notAlwaysUpdate'])


    def rebuild(self):
        """(Re)build the cscope files."""
        
        params = ['-b', '-q', '-i', self.cscope_files]
        
        if self.kernel_mode:
            params += ['-k']
        
        build = subprocess.Popen(
            args=[self.cscope] + params,
            cwd=self.project_dir,
            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        
        if build.wait():
            self.error("Problem building the cscope files: " + build.stderr.readlines()[0])
            return
            
    
    def edit_cscope_files(self):
        """
        Open the cscope files list in textmate for editing
        """        
        self.open_in_textmate(self.cscope_files)


    def open_in_textmate(self, filename, line = 0):
        """
        Open a file in textmate
        """
        subprocess.Popen(args = ["open", "txmt://open?url=file://%s&line=%s" % (filename, str(line))],
            stdout=subprocess.PIPE, 
            stderr=subprocess.PIPE).wait()
    
    
    def generate_cscope_files(self):
        """
        Generate a list of files for the current project to be used by cscope and save it
        """
             
        find = subprocess.Popen(args = ['find', '-E', '-s', '.', '-type', 'f', '-iregex', '.+\.(c|cc|cpp|cxx|h|hh|hpp|hxx|java|m)'],
            cwd = self.project_dir, 
            stdout=subprocess.PIPE, 
            stderr=subprocess.PIPE)
        
        with open(self.cscope_files, 'w') as f:
            for line in find.stdout:
                f.write(line)


    def get_available_queries(self):
        """Return a list of all the queries descriptions allowed"""
        return [CSCOPE_QUERIES_DESC[query] for query in CSCOPE_QUERIES_INDEX if query[0] != '_']
    
    
    def query_dialog(self):
        """
        Get the query from the user using the dialog box
        Return the cscope query type index, or -1 if it was cancelled
        """
        
        import plistlib
                        
        history = plistlib.readPlist(self.query_history)
        
        # When using the query dialog, give preference to the pasteboard as the default query
        self.query =  self.current_find or self.selected_text or self.current_word or ""

        params = {  'doFind' : 1, # If returned then a find was performed
                    'ignoreCase' : self.ignore_case, 
                    'queryString' : self.query,
                    'queryHistory' : [self.query] + history['queries'],
                    'queryType' : CSCOPE_QUERIES_DESC[history['lastQueryType']],
                    'queryTypeList' : self.get_available_queries(),}
        

                    
        outcomeString = subprocess.Popen(args = [self.tm_dialog, 
                                                '-p', plistlib.writePlistToString(params), 
                                                '-m', os.path.join(self.bundle_dir, 'cscope_dialog.nib')],
                                        stdout=subprocess.PIPE,
                                        stderr=subprocess.STDOUT).communicate()[0]
        
        outcome = plistlib.readPlistFromString(outcomeString)
        

        # Find wasn't clicked (hacky, I know...)
        if 'result' not in outcome:
            raise CscopeQueryError()

        self.query = outcome['queryString']
        queryType = CSCOPE_QUERIES_DESC_REV[outcome['queryType']]
        self.set_pasteboard(self.query)
        return queryType
       
 
    def add_query_history(self, new_query, query_type):
        """Adds the query to the query history if it's unique and non-empty"""
        import plistlib

        if not new_query:
            return

        history = plistlib.readPlist(self.query_history)

        if new_query in history['queries']:
            history['queries'].remove(new_query)
        
        while len(history['queries']) >= self.max_history_lines:
            history['queries'].pop()
        
        history['queries'].insert(0, new_query)        
        history['lastQueryType'] = query_type
        plistlib.writePlist(history, self.query_history)        


    def set_preferences_dialog(self):
        """Display the preferences dialog and save the preferences"""
        import plistlib
        
        outcomeString = subprocess.Popen(args = [self.tm_dialog, 
                                                '-m', os.path.join(self.bundle_dir, 'preferences_dialog.nib')],
                                        stdin = open(self.cscope_settings, 'r'),
                                        stdout = subprocess.PIPE,
                                        stderr = subprocess.STDOUT).communicate()[0]
    
        plistlib.writePlist(plistlib.readPlistFromString(outcomeString), self.cscope_settings)
        
        
    def print_results_html(self, query_type):
        """Print the results for the query with the number as HTML"""
        from cgi import escape

        print self.generate_html_header()
        print self.generate_html_busy()
        print '<h3>%s: %s</h3>' % (CSCOPE_QUERIES_DESC[query_type], escape(self.query))
        print self.generate_minimum_window_size(width="screen.width * 0.75", height="screen.height * 0.75")
        sys.stdout.flush()

        try:
            if not self.project_dir:
                raise CscopeProjectError()
        
            if not os.path.isfile(self.cscope_files):
                print "<em>cscopes files list does not exist, creating one now... "
                sys.stdout.flush()
                self.generate_cscope_files()
                print "done <br><br></em>"
                sys.stdout.flush()
            
            cscopeProcess = self.run_command(CSCOPE_QUERIES_INDEX.index(query_type))
            
            formatted_lines = []
            for line in self.seperate_output_line_items(cscopeProcess.stdout):
                field_file = self.textmate_link_html(escape(line[INDEX_FILE]) + ":" + line[INDEX_NUMBER], line[INDEX_FILE], line[INDEX_NUMBER])
                field_scope = self.format_scope_html(line[INDEX_SCOPE])
                field_text = '<code>' + escape(line[INDEX_TEXT]) + '</code>'
                formatted_lines.append([field_file, field_scope, field_text])
            
            cscopeProcess.wait()
            if cscopeProcess.returncode != 0:
                raise CscopeRunError(cscopeProcess.stderr.readlines()[0])
            
            if len(formatted_lines) == 0:
                print "No matches found."
            elif len(formatted_lines) == 1:
                print "1 matching line found."
            else:
                print "%d matching lines found." % len(formatted_lines)
            
            print "<br><br>"
            
            if formatted_lines:
                print self.generate_html_table(["File","Scope","Text"], formatted_lines)

        except CscopeRunError as exc:
            print self.error_html("when running cscope")
        except CscopeProjectError:
            print self.error_html("no project directory", "this bundle only works on Textmate projects")

        finally:
            print self.generate_html_not_busy()
            print self.generate_html_footer()
    
    
    def run_command(self, number):
        """
        Run the cscope command of the associated number
        Return the cscope process
        """
        
        params = ['-q', '-i', self.cscope_files]
        
        if self.ignore_case:
            params += ['-C']        
        if self.not_always_update:
            params += ['-d']            
        if self.kernel_mode:
            params += ['-k']

        try:
            null_file = open('/dev/null', 'w')

            return subprocess.Popen(
                args = [self.cscope] + params + ['-L', '-%d' % number, '%s' % self.query],
                cwd = self.project_dir,
                stdout=subprocess.PIPE, stderr=null_file)
                
        except OSError as exc:
            raise CscopeRunError("operating system error " + str(exc.args))
            
        finally:
            self.add_query_history(self.query, CSCOPE_QUERIES_INDEX[number])
    
    def get_pasteboard(self):
        """Return a string of contents of the pasteboard, or None if it's empty"""
        try:
            return subprocess.Popen(args = ["pbpaste", "-pboard", "find"], stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout.readlines()[0]
        except:
            return None
    
    
    def set_pasteboard(self, string):
        """Sets the pasteboard to the contents of string if it's not empty"""
        if string:
            pb = subprocess.Popen(args = ["pbcopy", "-pboard", "find"], stdin = subprocess.PIPE)
            pb.stdin.write(string)
            pb.stdin.close()
            pb.wait()    
    
    
    def get_environ(self, name):
        """Return a string for the environemnt variable name, None if it doesnt exist or is empty"""
        try:
            return os.environ[name]
        except KeyError:
            return None    
    
    
    def seperate_output_line_items(self, lines):
        """
        Create a list of lists of the cscope line items, each one representing one of the following elements:
        File Name, Line Number, Scope, Line Text
        """
        return [line.strip().split(None, ITEMS_PER_LINE - 1) for line in lines]
    
    
    def format_scope_html(self, scope):
        """Formats the scope of the cscope search result for HTML"""
        from re import match
        from cgi import escape
        
        if match(r"<.+>", scope):
            return "<em>" + escape(scope) + "</em>"
        else:
            return "<code>" + escape(scope) + "</code>"
        
    
    def generate_html_table(self, headers, rows):
        """
        Generate an HTML table from a list of headers and rows
        
        Adapted from http://groups.google.com/group/comp.lang.python/msg/8530840292254e50
        """
        html = []
        
        if headers:
            html.append("<tr>")
            for header in headers:
                html.append("<th>%s</th>" % header)
            html.append("</tr>")
        
        if rows:
            for row in rows:
                html.append("<tr>")
                for cell in row:
                    html.append("<td>%s</td>" % cell)
                html.append("</tr>")
        
        if html:
            html  = ['<table class="pro_table" cellspacing="0" cellpadding="5">'] + html + ['</table>']
        
        return "\n".join(html)
    
    
    def generate_html_header(self):
        """Generate an HTML header"""
        
        import webpreview        
        return webpreview.html_header("cscope", "")
    
    
    def generate_html_footer(self):
        """Generate an HTML footer"""
        import webpreview
        return webpreview.html_footer()
    
    
    def textmate_link_html(self, string, filename, lineno):
        """Generate an HTML for the file/line number combination with the string"""
        from cgi import escape
        return '<a href = "txmt://open?url=file://%s&line=%s">%s</a>' % (os.path.join(self.project_dir, filename), lineno, string)
    
    
    def generate_html_busy(self):
        """Generate the 'busy' sign"""
        return '<script type="text/javascript">TextMate.isBusy = true;</script>'
    
    
    def generate_html_not_busy(self):
        """Remove the 'busy' sign"""
        return '<script type="text/javascript">TextMate.isBusy = false;</script>'


    def generate_minimum_window_size(self, width=0, height=0):
        """Generate javascript to resize a window to the minimum size if it's not already that big"""
        
        # screen.width/2,screen.height

        return """  <script type="text/javascript">
                    var width = %s;
                    var height = %s;
                    if (width < window.innerWidth) width = window.innerWidth;
                    if (height < window.innerHeight) height = window.innerHeight;
                    self.resizeTo(width,height);</script>
                    """ % (str(width), str(height))


    def generate_close_window(self):
        """Generate javascript to close the window"""        
        return '<script type="text/javascript">window.close()</script>'
    
    
    def is_valid_posix_filename(self, name, NAME_MAX=255):
        """Checks for a valid POSIX filename

        Filename: a name consisting of 1 to {NAME_MAX} bytes used to name a file.
            The characters composing the name may be selected from the set of
            all character values excluding the slash character and the null byte.
            The filenames dot and dot-dot have special meaning.
            A filename is sometimes referred to as a "pathname component".

        name: (base)name of the file
        NAME_MAX: is defined in limits.h (implementation-defined constants)
                  Maximum number of bytes in a filename
                  (not including terminating null).
                  Minimum Acceptable Value: {_POSIX_NAME_MAX}
                  _POSIX_NAME_MAX: Maximum number of bytes in a filename
                                   (not including terminating null).
                                   Value: 14

        More information on http://www.opengroup.org/onlinepubs/009695399/toc.htm
        
        From: http://code.activestate.com/recipes/415680/
        """
        return 1 <= len(name) <= NAME_MAX and '/' not in name and '\000' not in name
